Repository navigation
ci: add make ci-remote and make the repo's hooks fail closed - #798
Conversation
A code push needs tmp/ci-passed-tree-<tree>, and only a make ci in the
same checkout wrote it, so every push paid for a full local run and the
gate only ever exercised the contributor's own OS and architecture.
make ci-remote HOST=<ssh destination>|docker:<container> streams HEAD to
the host as a git bundle on the transport's stdin, checks it out in a
fresh directory, runs make tools && make ci there and streams the output
back. The same markers are written locally only if the host exits 0, the
tree it reports having checked out is HEAD^{tree}, make ci left that
checkout clean, and the local worktree is clean before and after the run
and still on that tree. Report lines carry a per-run nonce so make's own
output can't forge them. A provenance file beside the marker records
where the run happened, and the pre-push hook prints it.
scripts/ci/remote-ci.test.sh (a make verify leaf) runs the script through
stand-in ssh and docker commands in a scratch repository.
The host run stops whenever the script exits, however it ends: the host
keeps its stdin open and a watcher stops the run's process group when it
closes. Each run writes its own log, named with its nonce. The docs
cover interrupts, host-network containers and the host's requirements.
Closes #764. Part of #740.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH
The pre-push hook is about to pass a pushed commit that has no Makefile, since it holds no code to validate (#678). This test's scratch repository had none, so its pre-push cases would stop testing the marker check: the push would pass as code-less, and "pre-push blocks a code push with no marker" and "pre-push accepts the marker and names the host" would fail. A code repository has a Makefile, so the scratch one now does too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH
With an absolute core.hooksPath, git runs one checkout's .githooks/ in
every worktree, including one on an orphan branch that keeps a tool's
state and no code. There pre-commit could not find scripts/ci-marker.sh,
hid the error behind 2>/dev/null, and ran `make verify` with no
Makefile ("No rule to make target 'verify'"), so nothing could be
committed (#678).
- pre-commit: a worktree with no Makefile has nothing to verify and
exits 0. A missing scripts/ci-marker.sh, or one that fails other than
"no marker" (exit 1), is reported and `make verify` runs.
- pre-push: a pushed commit with no Makefile holds no code to validate
and goes through. One with a Makefile, pushed from a worktree without
scripts/ci-marker.sh, is blocked with a message that says so, instead
of "can't resolve tree". The decision follows what is pushed, not the
worktree it is pushed from, so a code branch can't slip through a
code-less worktree.
- scripts/githooks.test.sh (make test-githooks, a verify leaf) commits
and pushes through the real hooks from scratch worktrees, with
core.hooksPath pointing at them by absolute path.
- make lint-sh now shellchecks the extensionless .githooks/ files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH
Each hook in .claude/settings.json ran $CLAUDE_PROJECT_DIR/.claude/hooks/<script>.sh, and CLAUDE_PROJECT_DIR is fixed when the session starts. Once that checkout was removed, or a script went missing or lost its executable bit, the shell exited 127 or 126. Claude Code treats any exit code but 2 as a non-blocking error, so the push gate stopped gating and the marker hook stopped writing markers, silently (#787). Each command is now a short POSIX sh guard. When the script is there and executable it is exec'd with the same stdin, argv and environment as before. Otherwise, by event: - PreToolUse (Bash): exit 2 for any command containing the words `git` and `push`, or `gh` with `pr` or `api` and one of create, edit, ready, review, merge or requested_reviewers; and for every command when jq is missing or the payload is unreadable, as the gate itself does. Other commands go through, so the session can still work. - SubagentStop: exit 1 with a one-line stderr message. Exit 2 would keep the subagent running; the push gate then finds no marker. - PostToolUse: exit 2, which shows Claude the message after an edit that already happened. Every fallback reads its payload to the end and names the missing script and the fix: restart Claude Code from a live checkout. The quoted path also fixes a project directory with a space in it. .claude/hooks/hook-commands.test.sh (make test-hook-commands, a verify leaf) reads each command from the real settings.json and runs it under sh -c (and dash and bash when present) with an empty project directory, CLAUDE_PROJECT_DIR unset, no jq, a non-executable script, stub scripts that record their stdin and argv, and this checkout's real scripts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH
Review follow-up to the fail-closed hook guards (#787). - hook-commands.test.sh tested a script that is there but not executable only for PreToolUse, though the docs say every command is tested that way. It now runs every command in settings.json against a non-executable copy of each real script as well as against an empty project directory, with the same assertions (not exit 0, 126 or 127, and a message naming the script), plus the exact SubagentStop and PostToolUse codes. Dropping `[ -x "$h" ] &&` from the SubagentStop guard now fails 6 cases (exit 126); before, all passed. - The PreToolUse fallback's word list let through the API and alias forms of what the gate guards: `gh pr new`, `gh api .../pulls/N/reviews -f event=APPROVE`, `gh api .../pulls -f head=...` (a non-draft PR) and a GraphQL markPullRequestReadyForReview. It now blocks `gh` and `pr` with create, new, edit, ready, review or merge, and `gh` and `api` with pulls or graphql: every pull-request or GraphQL API call, reads included. Still crude, still erring toward blocking, and only in play while the real gate can't run. The test, AGENTS.md, the Claude Code docs and the changelog entry list the new words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH
agent-bash-gate.sh matched only the `gh pr` spellings, so each action it guards went through in another form: `gh pr new` skipped the draft check and the title lint; `gh api` approved a PR, opened a non-draft one, requested a reviewer (`reviewers[]=` with no -X) and merged; and GraphQL marked a draft ready. - `gh pr new` is checked like `gh pr create`. - A `gh api` call writes when it names a method other than GET or, naming none, sends fields. A write to requested_reviewers (DELETE included) is blocked; one to .../pulls/N/merge is blocked as a merge; one that opens a PR (.../pulls) needs draft=true, and its title= goes through the PR-title lint, as does a retitle (PATCH .../pulls/N). - A review with event=APPROVE or REQUEST_CHANGES is blocked. - GraphQL: markPullRequestReadyForReview, mergePullRequest, enablePullRequestAutoMerge, requestReviews, an approving or changes-requesting add/submitPullRequestReview, and a createPullRequest without draft: true are blocked. - `gh pr merge` itself stays with the settings.json deny rule and out of the gate: a person merges with it from shell mode (`!`), and the Claude Code docs don't say whether PreToolUse hooks see those. The gh checks read the line with quoted strings removed and the rest kept, so a guarded command only mentioned in a heredoc body or a comment was blocked, and one in a $(...) or `bash -c` was missed. They now read the commands the line runs through the gate's own tokenizer, like the push check; code a shell runs (a $(...), `bash -c`, a heredoc a shell reads directly or through a pipe) still counts. Titles come from the tokens, so an unquoted --title is linted too. The tokenizer takes plain text inside double quotes in one step, so a 100 KB inline --body no longer costs about 11 s per command. review-gate.test.sh: 44 new cases, the blocked forms, look-alikes that read (GET pulls, reviews, requested_reviewers, merge status; a COMMENT review; a GraphQL query) and mentions in heredocs and comments; 24 of them fail against the previous gate. Closes #790 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH
Review follow-up to the agent gate's gh checks (#790). - bash 3.2: `for s in "${TK_SUBS[@]}"` stopped the gate with "unbound variable" under `set -u` when a line had no substitution, on macOS's /bin/bash 3.2.57. The gate then exited 1, which Claude Code doesn't treat as a block, so every line mentioning gh went through, a push on the same line included. With the hooks under /bin/bash the review-gate suite gave 135 ok and 46 FAIL. The scan now walks its arrays by index and never expands one that may be empty. - Code a shell runs is judged like the line itself: the word after `bash -c` (or -lc and the like), eval's words, each $(...) and backtick body, and a heredoc a shell reads are queued, tokenized and scanned in turn (at most 64). `bash -c 'gh api -X PUT .../merge'` and the like were allowed. - Each gh command is judged on its own words. A gh api call is judged by its endpoint, its first argument that isn't a flag, and by its fields: draft=, title= and event= only from -f/-F/--field/--raw-field values, GraphQL mutation names only from query=. A review-thread reply whose body quoted a merge endpoint, an issue comment naming requested_reviewers, and a reviewer read followed by a comment on one line were blocked. - Boolean flags set with = are read as gh reads them: --draft=true was blocked as not a draft, --approve=true and --request-changes=true went through. - A line the tokenizer can't parse is refused when it mentions a gh pr or gh api command. - review-gate, hook-commands and githooks tests run a second time with /bin/bash first on PATH when it is a different bash, so a construct bash 3.2 rejects fails a test. 21 new review-gate cases cover the above; against the previous gate, 11 fail under bash 5 and 67 under /bin/bash 3.2. The changelog entries follow, and the #787 entry now quotes the Claude Code docs for what CLAUDE_PROJECT_DIR is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH
Docs review follow-up for the hooks and make ci-remote.
- AGENTS.md and the Claude Code guide say each gh api call is judged by
its own endpoint and fields, that code a shell runs (including eval)
is judged the same way, and that an unparseable line naming a gh pr
or gh api command is refused.
- AGENTS.md's fail-closed paragraph links the exit-127 (non-blocking)
and SubagentStop exit-2 claims to the Claude Code hooks docs, and
points to the guide's "When a hook script is missing" section.
- The guide quotes the docs' definition of CLAUDE_PROJECT_DIR ("the
project root where the session started") instead of paraphrasing it.
- The development guide's make ci-remote section: the local
"==> make ci on ..." line comes first; the host's `uname -sm` is the
first line the host prints.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH
Last review round on the agent gate's gh checks (#790). - The queue of nested code stopped at 64 pieces and returned 0, so a gh command in the 65th went unjudged (63 `$(true)`, then `$(gh pr ready 12)`). Past the cap, a piece that mentions a gh pr or gh api command is now refused. - gh resolves -R/--repo before the command (`gh -R o/r pr ready 12`), and the gate read only the word right after gh. It now steps over -R, --repo, -R<repo> and --repo=<repo> first, for pr and api both. The unparseable-line and past-the-cap checks know the same forms. - A GraphQL query computed by a heredoc inside $(...) was unseen: the tokenizer keeps no heredoc body inside a substitution. When a graphql call's query= value is computed, the guarded mutation names are now matched against the code being judged, heredoc bodies included. - A shell handed code only when -c was the next word. It now steps over options, including the value of -o, +o, -O, +O, --rcfile and --init-file, treats a short-option cluster containing c as -c (and not a long option such as --norc), and queues the first argument after them: `bash -e -c`, `bash -euo pipefail -c`, `bash --noprofile --norc -c`. The push check already read every word after a shell; the tests now pin that both agree on these forms. 21 new review-gate cases cover the above, plus look-alikes that must go through (gh -R ... pr view, a heredoc GraphQL read, `bash --norc` running a script, a read under `bash -o pipefail -c`). Against the previous gate, 12 fail, under bash 5 and /bin/bash 3.2 alike. The changelog's timing note no longer names a commit the squash will drop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH
- The Claude Code guide's agent-bash-gate.sh row: an unparseable line is refused when it mentions a push or a gh pr / gh api command, not unconditionally, and a gh command beyond the 64 pieces of nested code the gate follows is refused unjudged. - AGENTS.md and the guide's "API forms" bullet: `gh -R <repo> pr ...` counts as `gh pr ...`, options before a shell's -c don't hide its code, and a GraphQL query computed by a heredoc inside $(...) is read from that heredoc. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH
The gh checks queued each $(...) from the tokenizer's TK_SUBS text, whose quotes are \002/\003 markers, and deleted the markers. Tokenized again, the code split quoted arguments on spaces, `(` and `)` (#790): - resp=$(gh api graphql -f query='mutation { markPullRequestReady... }') read as query=mutation and ended at the `(`, so every GraphQL mutation inside $(...) went through; - url=$(gh pr create --draft --title "feat(x): ..." --body-file b.md) was blocked by the title lint, the title cut at the `(`; - out=$(gh pr comment 12 --body "it's fixed") was refused as unparseable; - n=$(grep -c 'gh pr ready' AGENTS.md) was read as a real gh pr ready. The tokenizer now also keeps, in TK_CODE, the code inside each $(...) and backtick substitution exactly as written, quotes and heredoc bodies included, and the gh checks queue that. TK_SUBS keeps its marker form for the push check. With the heredoc bodies kept, a heredoc a shell reads inside $(...) is now judged too. The other code the gh checks re-read (a shell's -c argument, eval's words, heredoc bodies) was already the text the shell itself runs. 7 new review-gate cases, under bash 5 and /bin/bash 3.2: the four above plus a scoped title, a bad title still linted inside $(...), and a gh pr ready in a heredoc read by bash inside $(...). 6 of them fail against the previous gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (5)
Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review. 📜 Recent review details🧰 Additional context used📚 Code guidelines (3)📓 Path-based instructions (3)See [AGENTS.md](../AGENTS.md) for project conventions, architecture notes, and AI agent instructions.📄 CodeRabbit inference engine (.github/copilot-instructions.md) Files:
Source excerpt: **Never hard-wrap prose.📄 CodeRabbit inference engine (AGENTS.md) Files:
Source excerpt: Run `make fix` to apply them.📄 CodeRabbit inference engine (docs/src/content/docs/development.md) Files:
🪛 LanguageToolAGENTS.md[style] ~255-~255: Consider using the typographical ellipsis character here instead. (ELLIPSIS) 🔇 Additional comments (5)
📝 SummarySummary by CodeRabbit
WalkthroughThe pull request expands Claude Code command gates, adds remote CI execution that can write local validation markers, and changes Git hook behavior for worktrees without a ChangesClaude Code Command Gates
Remote CI
Git Hook Behavior
Priority: ➖ Normal Estimated code review effort: 4 (Complex) | ~60 minutes Change: Feature · Severity of issue fixed: Medium Sequence Diagram(s)sequenceDiagram
participant Makefile
participant RemoteCIScript
participant SSHorDocker
participant RemoteCheckout
participant LocalMarkers
Makefile->>RemoteCIScript: invoke ci-remote
RemoteCIScript->>SSHorDocker: send HEAD bundle and remote script
SSHorDocker->>RemoteCheckout: create temporary checkout
RemoteCheckout->>RemoteCheckout: run make tools and make ci
RemoteCheckout-->>RemoteCIScript: report tested tree and result
RemoteCIScript->>LocalMarkers: write markers and provenance after validation
Suggested reviewers: Merge Risk: ⚪ Minimal · up to No actionable merge blocker was established. Normal validation remains appropriate before merging. Pre-merge checks |
|
|
📚 Docs preview is live → https://f8e452ba-wavehouse-docs.wave-rf.workers.dev
|
Code Coverage OverviewLanguages: Go GoThe overall line coverage in commit ccbea68 in the Show a line coverage summary of the most impacted files.
Updated |
Brings in #789, #791, #786 and the PRs merged since. CHANGELOG.md conflicted only because new Fixed entries landed at the same spot; all are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH
|
@coderabbitai review |
|
Both PRs added their Fixed entries at the top of the section after merging main, so whichever merged second would conflict. These now sit below the TestClaims entry from main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
- Configuration used: Organization UI
- Review profile: ASSERTIVE
- Plan: Advanced
- Run ID:
f7308005-7ac3-41ba-b7a9-80ee477b8694
📒 Files selected for processing (16)
.claude/hooks/agent-bash-gate.sh.claude/hooks/hook-commands.test.sh.claude/hooks/review-gate.test.sh.claude/settings.json.githooks/pre-commit.githooks/pre-pushAGENTS.mdCHANGELOG.mdCONTRIBUTING.mdMakefiledocs/src/content/docs/claude-code.mddocs/src/content/docs/development.mdscripts/ci-marker.shscripts/ci/remote-ci.shscripts/ci/remote-ci.test.shscripts/githooks.test.sh
Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.
📜 Review details
🧰 Additional context used
📚 Code guidelines (3)
.github/copilot-instructions.md — auto-discovered
docs/src/content/docs/development.md — auto-discovered
docs/src/content/docs/claude-code.md — auto-discovered
📓 Path-based instructions (3)
See [AGENTS.md](../AGENTS.md) for project conventions, architecture notes, and AI agent instructions.
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
Files:
AGENTS.md
Source excerpt: Run `make fix` to apply them.
📄 CodeRabbit inference engine (docs/src/content/docs/development.md)
Files:
CONTRIBUTING.mdCHANGELOG.mddocs/src/content/docs/development.mddocs/src/content/docs/claude-code.mdAGENTS.md
Source excerpt: The `.claude/` layout is intentionally small.
📄 CodeRabbit inference engine (docs/src/content/docs/claude-code.md)
Files:
.githooks/pre-commit.githooks/pre-push
🧠 Learnings (1)
📓 Common learnings
Learnt from: CR
Repo: Wave-RF/WaveHouse
Timestamp: 2026-10-09T19:33:38.774Z
Learning: Source excerpt:
# AGENTS.md — AI Agent Instructions for WaveHouse
## Documentation Sync
Every code change should update the corresponding docs in the same PR. A code change without its doc update is incomplete.
🪛 ast-grep (0.45.3)
scripts/ci/remote-ci.test.sh
[warning] 131-131: Writing to or reading from a hardcoded, predictable path under /tmp is vulnerable to symlink and TOCTOU attacks: a local attacker can pre-create the file (or a symlink pointing elsewhere) and hijack or corrupt the contents. Generate a unique, unpredictable temporary file with mktemp instead, e.g. tmpfile="$(mktemp)" (or mktemp -d for directories) and reference "$tmpfile".
Context: "$repo/tmp/ci-passed-tree-$1"
Note: [CWE-377] Insecure Temporary File.
(predictable-tmp-file-bash)
🪛 LanguageTool
AGENTS.md
[style] ~176-~176: Since ownership is already implied, this phrasing may be redundant.
Context: ...in each worktree, so each worktree runs its own branch's hooks, and a worktree whose br...
(PRP_OWN)
[style] ~249-~249: Since ownership is already implied, this phrasing may be redundant.
Context: ...reads). Each gh api call is judged by its own endpoint (its first argument that isn't...
(PRP_OWN)
[style] ~249-~249: Since ownership is already implied, this phrasing may be redundant.
Context: ...s first argument that isn't a flag) and its own fields, so a comment body that names an...
(PRP_OWN)
[style] ~251-~251: This sentence is over 40 words long. Consider splitting it up, as shorter sentences make the text easier to read.
Context: ...alid; it prints the reason on failure). .claude/hooks/agent-bash-gate.sh runs the same check on gh pr create / gh pr new / gh pr edit --title, and on the title= field of a gh api call that opens or edits a PR, so a malformed title is caught locally before the PR exists. The rule has a single source of truth —...
(TOO_LONG_SENTENCE)
[style] ~255-~255: Consider using the typographical ellipsis character here instead.
Context: ...ssignee , any gh apiwrite to/repos/.../pulls//requested_reviewers`, or the ...
(ELLIPSIS)
🔇 Additional comments (17)
.claude/settings.json (2)
39-39: Block every command when the payload cannot be parsed, as the message says.The jq fallback runs only when
jqfails. In that casecbecomesgit push, and the guard blocks the command, so this path works. The message says that without jq every command is blocked, and the test at Line 207 ofhook-commands.test.shconfirms that behavior. One more case is also covered: a payload that is not JSON makesjqexit non-zero, so the guard blocks it. I found no defect on this line.
51-55: LGTM!Also applies to: 65-65
.claude/hooks/hook-commands.test.sh (1)
1-321: LGTM!AGENTS.md (1)
9-9: LGTM!Also applies to: 172-179, 243-244, 249-255, 295-295
CHANGELOG.md (1)
56-56: LGTM!Also applies to: 123-125
docs/src/content/docs/claude-code.md (1)
27-27: LGTM!Also applies to: 39-40, 46-47, 52-58, 109-111, 119-130
docs/src/content/docs/development.md (1)
22-22: LGTM!Also applies to: 343-358, 552-552, 568-568
scripts/ci/remote-ci.sh (1)
1-160: LGTM!scripts/ci/remote-ci.test.sh (1)
1-341: LGTM!Makefile (1)
99-99: LGTM!Also applies to: 432-436, 516-540, 631-637, 651-651, 1048-1057
.githooks/pre-commit (1)
4-36: LGTM!.githooks/pre-push (1)
7-24: LGTM!Also applies to: 41-53, 59-66, 78-78
scripts/githooks.test.sh (1)
1-180: LGTM!scripts/ci-marker.sh (1)
6-8: LGTM!CONTRIBUTING.md (1)
40-40: LGTM!.claude/hooks/agent-bash-gate.sh (1)
44-60: LGTM!Also applies to: 62-72, 74-100, 111-120, 122-318, 363-365, 410-410, 426-445, 458-466, 576-576, 933-936, 1000-1001
.claude/hooks/review-gate.test.sh (1)
12-29: LGTM!Also applies to: 516-664
The agent gate blocked adding reviewers or assignees on edit, but creating a PR with --reviewer/-r or --assignee/-a went through. Block them on create and its alias new with the reviewer message, glued short forms included; on review, -r and -a still mean --request-changes and --approve. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH
|
@coderabbitai review |
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
This PR has two parts:
make ci-remoteruns the pre-push gate'smake cion another machine..githooks/and the Claude Code hooks in.claude/. Each one failed the wrong way when a file it runs was missing, or when an action came in a form the hook didn't recognise (fix(hooks): pre-commit fails in any worktree without a Makefile, so a non-code worktree cannot commit #678, fix(agents): review-gate hooks fail open when their scripts are missing #787, fix(agents): the push gate misses gh pr new and the REST/GraphQL forms of PR operations #790).They share the pre-push hook, the verify leaves and the hook docs, so they ship together and their overlaps are resolved once.
Part 1:
make ci-remoteA code push needs
tmp/ci-passed-tree-<tree>, and only amake ciin the same checkout wrote it. So every push paid for a full local run (race detector, coverage, a Docker daemon carrying every suite's containers), and the gate only ever exercised the contributor's own OS and architecture.make ci-remote HOST=<host>runs thatmake cion another machine and keeps the gate's meaning.HOSTis any ssh destination (a~/.ssh/configalias works), ordocker:<container>for a local container or devcontainer. Over ssh the commands run in a login shell; in a container, in its own environment.HEADis streamed as agit bundleon the transport's stdin, right after the host-side script, so nothing is pushed. The host checks out that commit in a fresh directory underREMOTE_DIR, runsmake tools && make ci, streams the output back with itsuname -smfirst, and removes the directory unlessKEEP=1. Stopping the command, by Ctrl-C or a signal to the script alone, stops the run on the host: the host's stdin stays open only while the script runs, and a watcher stops the run's process group when it closes.HEAD^{tree};make cileft its checkout clean; and the local worktree is still clean and on that tree at the end. The host's report lines carry a per-run nonce, somake ci's own output can't forge them. The verify marker is written too, asmake cidoes.tmp/ci-passed-tree-<tree>.provenancerecords the host, OS/arch, start and end times, and the run's own local copy of the log (tmp/ci-remote-<tree>-<run>.log, so concurrent runs of one tree don't overwrite each other). The pre-push hook printsmake ci passed on <host> (<os>/<arch>)from it. Marker semantics and localmake ciare unchanged.make ci-remotesection in the development guide (usage, host requirements, how the gate treats it), the AGENTS.md pre-push rules, the hook table, and CONTRIBUTING.md's pre-push sentence.Part 2: hooks that fail closed
pre-commitfailed loudly in a worktree with no code, so nothing could be committed there.gh prspelling of each action it guards, so the alias and API forms went through. Itsghchecks also blocked commands that only mentioned a guarded action in a heredoc or a comment.Each now fails closed and says why.
#678: git hooks in a worktree with no Makefile
With an absolute
core.hooksPath, git runs one checkout's.githooks/in every worktree, including one on an orphan branch that keeps a tool's state and no code. There,pre-commitcouldn't findscripts/ci-marker.sh, hid that behind2>/dev/null, and ranmake verifywith no Makefile (No rule to make target 'verify').pre-commit: noMakefilein the worktree means nothing to verify, so it exits 0 with a note. A missingscripts/ci-marker.sh, or one that fails with anything but "no marker" (exit 1), is now reported, and thenmake verifyruns, which is the fail-closed path.pre-pushhad the same failure, reported as a misleading "can't resolve tree". It now decides per pushed commit, not per worktree. A pushed commit with noMakefileholds no code to validate, so it goes through. One with aMakefile, pushed from a worktree that lacksscripts/ci-marker.sh, is blocked with a message naming the script. Deciding by worktree would have failed open:git push origin <code-branch>from a worktree with no code would have skipped the check.make ci-remotetest needed one fix for this. Its scratch repository had noMakefile. So once the pre-push hook passed a commit without one, the test's two pre-push cases ("blocks a code push with no marker" and "accepts the marker and names the host") stopped exercising the marker check and failed. A code repository has a Makefile, so the scratch one now does too, in its own commit.On
core.hooksPath. This PR doesn't change it.make toolssets the relative.githooks, and git resolves a relativecore.hooksPathin each worktree (checked with git 2.56). So each worktree runs its own branch's hooks, and a worktree whose branch has no.githooks/runs no hooks at all. That fails open, the class of bug #787 is about. #678 was found with an absolute path, which runs one checkout's hooks everywhere. Switching to a relative path would have "fixed" #678 by turning a loud failure into a silent one. So the fix is in the hooks, which now behave correctly under either setting.#787: Claude Code hooks whose scripts are missing
Each hook in
.claude/settings.jsonwas a bare$CLAUDE_PROJECT_DIR/.claude/hooks/<script>.sh.CLAUDE_PROJECT_DIRis fixed when the session starts. Once that checkout is removed, or a script loses its executable bit, the shell exits 127 or 126. Claude Code treats that as a non-blocking error: "When the script path doesn't exist or isn't executable, the shell exits with a code like 127 … For most hook events, the action proceeds. … a mistyped path insettings.jsonleaves the gate silently disabled."The scripts may be the thing that's missing, so the fix has to live in the commands. Each is now a one-line POSIX
shguard;sh -cis how Claude Code runs a shell-form hook on macOS and Linux.When the script is there and executable, the guard
execs it, with the same process, stdin, argv and environment as before. The only visible change is that the path is now quoted, so a project directory with a space in it works.When the script isn't there, the guard acts by event:
gitandpush;ghandprwith one ofcreate,new,edit,ready,reviewormerge; orghandapiwithpullsorgraphql. That last rule blocks every call to the pull-request or GraphQL API, reads included. Also exit 2 for every command whenjqis missing or the payload can't be parsed. Every other command: exit 0.PreToolUseblocks the tool call", and exit 2 is "the only exit code that blocks through the code alone" (Exit code 2).<hook name> hook errornotice followed by the first line of stderr" (Other exit codes), so the message is one line. No marker gets written, so a push is still blocked, by the push gate or by the PreToolUse fallback above.PostToolUse… hook, exit 2 instead" (Exit code 0). Stderr from exit 0 "goes to the debug log only … Claude never sees it", and exit 1 only reaches the transcript notice.Each message names the missing script and says how to recover: restart Claude Code from a live checkout.
Design notes:
jq: then the command can't be read, so every command is blocked, which is whatagent-bash-gate.shalready does withoutjq.git stash push,git pushmentioned in a commit message, and a read-onlygh api …/pulls/N/commentsare all blocked. It never runs while the real gate can run, and like the gate it guards against accidents, not deliberate workarounds. Only.tool_input.commanddecides; the Bash tool'sdescriptionfield is ignored.jqisn't guaranteed. It's a documented prerequisite (make verifyand the hooks need it), not a given, so its absence fails closed.Writepayload to a hook that never reads it.execwould mean giving up theexec.#790: the agent gate missed the alias and API forms of what it guards
agent-bash-gate.shmatched only thegh prspellings, so each guarded action went through in another form:gh pr new, an alias ofgh pr create, skipped the--draftcheck and the title lint.gh api …/pulls/N/reviews -f event=APPROVEapproved a PR.gh api …/pulls -f head=… -f title=…opened a non-draft PR.gh api …/requested_reviewers -f 'reviewers[]=…'requested a reviewer. The check wanted-X POSTor a literalreviewers=, though a field already makesgh apiPOST.gh api -X PUT …/pulls/N/mergemerged.markPullRequestReadyForReviewmarked a draft ready.Each of these now gets the same answer as its
gh prform:gh pr newis checked likegh pr create.gh apicall counts as a write when it names a method other than GET or, naming none, sends fields.…/requested_reviewers(DELETEincluded) is blocked.…/pulls/N/mergeis blocked.…/pulls) needsdraft=true. Itstitle=goes through the PR-title lint, as does a retitle (PATCH …/pulls/N).event=APPROVEorevent=REQUEST_CHANGESis blocked.markPullRequestReadyForReview;mergePullRequestandenablePullRequestAutoMerge;requestReviews;addPullRequestRevieworsubmitPullRequestReviewthat approves or requests changes;createPullRequestwithoutdraft: true.gh pr mergestays with thedenyrule in.claude/settings.jsonand out of the gate. A person merges with it from Claude Code's shell mode (! gh pr merge …), and the docs don't say whether PreToolUse hooks see shell-mode commands. The shell-mode section says only that they run "without going through Claude" and don't "require Claude to interpret or approve the command"; the hooks reference doesn't mention them. Blocking it in the gate could break that workflow. The API and GraphQL merge forms don't overlap with it, so the gate blocks them.The checks now read what the line runs, not what it mentions. The
ghchecks used to read the line with quoted strings removed and everything else kept. That made them wrong both ways:python3 - <<'EOF'script with# gh pr readyin it.$(…)or handed tobash -c.They now read the commands the line runs, through the gate's own tokenizer, the same view the push check already uses:
$(…),eval's words, a heredoc a shell reads directly or through a pipe, and the code a shell is handed with-c. Options before-care stepped over, including the value of-o/+o:bash -euo pipefail -c '…'andbash --noprofile --norc -c '…'both count. A short-option cluster containingccounts as-c; a long option such as--norcdoesn't.$(…)is read as written, quotes and heredoc bodies included. Sourl=$(gh pr create --draft --title "feat(x): …")is judged on its real title, and a heredoc a shell reads inside a$(…)counts too. (The push check still doesn't follow that last form.)query=computed by a command (-f query="$(cat <<'EOF' … EOF)") has its guarded mutation names matched against the code that computes it, heredoc bodies included.gh prorgh apicommand is refused, unjudged.gh prorgh apicommand, because what it runs can't be told.Each
ghcommand is judged on its own words.gh apicall is judged by its endpoint (its first argument that isn't a flag) and by its fields.draft=,title=andevent=are read only from-f,-F,--fieldor--raw-fieldvalues, and GraphQL mutation names only from thequery=field. So a review-thread reply or comment whose body names an endpoint, a field or a mutation doesn't count, and neither does another call on the same line.-R/--repobefore the command is stepped over (gh -R o/r pr ready 12,--repo=o/r,-Ro/r), forprandapiboth.=is read as gh reads it:--draft=truecounts as--draft, and--approve=falsedoesn't approve.--titleis linted too.The gate still reads the command line only, so fields sent through
--inputor-F field=@filego unseen.It runs under macOS's bash 3.2. On a Mac with no newer bash on
PATH,#!/usr/bin/env bashfinds/bin/bash3.2. An earlier revision of this branch stopped there with "unbound variable" whenever a gh line had no substitution, because it expanded an empty array underset -u. The gate then exited 1, which Claude Code doesn't treat as a block. The scan now walks its arrays by index. The three hook tests run a second time with/bin/bashfirst onPATHwhen it is a different bash, so a construct 3.2 rejects fails a test.Timing. The tokenizer used to step through a double-quoted string one character at a time; it now takes runs of plain text in one step. Measured on an earlier revision of this change (a 100 KB inline
--body "…": 11 s with the old character loop, under 1 s after); not re-measured after the per-call rewrite.Test plan
On this branch at its head, 9b8c30c (measured):
make verifypasses with all 23 leaves.make build-docspasses.*.shand.githooks/*files.make verifypassed on each commit that went through it.make ci: not run.Part 1,
make ci-remote. Carried over from the stacked version of this PR (#766) unless marked as re-run here.scripts/ci/remote-ci.test.sh, a newmake verifyleaf (test-remote-ci): 31/31 on this branch (re-run here). It runs in a scratch repository, which now has a Makefile, with stand-inssh/dockercommands that run the host side for real, in its own process group as sshd would, against a stubmake,unameandgit. It covers:KEEPandREMOTE_DIR;make ci;make cidirtying the host's checkout;make cistops and its directory goes;make ci's output;HOSTrefusals withstatus.showUntrackedFiles=noset;docker execcommand line;make cipassed there,remote-ci testincluded.make ciran inside a container on the host's network, so testcontainers dialed the bridge gateway and timed out;TESTCONTAINERS_HOST_OVERRIDE=localhostis the documented fix.make cion a remote host has not been run yet.ssh -Tanddocker exec -iboth return within a second of the remote command exiting while local stdin is still open, which the interrupt watcher relies on. (Carried over.)Part 2, hooks that fail closed (measured):
make test-githooks(scripts/githooks.test.sh, new verify leaf): 11/11 pass, under bash 5.3 and again with/bin/bash3.2 first onPATH.core.hooksPathnames this checkout's.githooks/by absolute path, then commits and pushes from a code worktree and from an orphan worktree with no Makefile.git -Cthroughout, and refuses to write unless each scratch repository and worktree is where it asked.make: *** No rule to make target 'verify'for the commit with no Makefile.make test-hook-commands(.claude/hooks/hook-commands.test.sh, new verify leaf): 171/171 pass undersh,dashandbash, and 171/171 again with/bin/bash3.2 first onPATH.settings.jsonand runs it under each shell.CLAUDE_PROJECT_DIR, a non-executable copy of every script, the variable unset, and nojq.[ -x "$h" ] &&from the SubagentStop guard fails 6 cases.settings.json, 141 of 171 fail. The 30 that pass are exactly the stub-script and real-checkout cases, which shows the commands behave as before when the scripts exist.make test-review-gate: 230/230 pass under bash 5.3, and 230/230 with/bin/bash3.2.57 first onPATH.$(…), read as written:$(…); a bad title is still linted there; agh pr readyin a heredoc read bybashinside$(…).grep -c 'gh pr ready'.-X GETwith fields; a COMMENT review; a GraphQL query; a GraphQL draft create; an issue comment that mentions approving and merging.catheredoc, a shell comment, a quoted string, a PR-body heredoc.$(…),bash -c,bash -lc,sh -coreval, in a heredoc read bybashor piped intosh, and behindtimeout.=-flags in both directions:--draft=trueallowed,--draft=falseblocked,--approve=trueand--request-changes=trueblocked,--approve=false --commentallowed.requested_reviewersorevent=APPROVE, and a GraphQL comment naming a mutation are allowed. A reviewer read followed by a comment is allowed; a comment followed by a reviewer write is blocked. A non-draft create whose body saysdraft=trueis blocked.-R/--repoforms: blocked for ready, review--approve, create without--draft, edit--add-reviewerand an API merge.gh -R o/r pr viewis allowed.gh pr readyin the 63rd$(…)is judged and blocked, one past the 64 pieces the gate follows is refused, and a gh read beside 70 substitutions with no gh in them is allowed.-c:-e -c,-euo pipefail -c,-o pipefail -c,+o posix -cand--noprofile --norc -care all followed, by the gh check and the push check alike.bash --norcrunning a script and a read underbash -o pipefail -care allowed.$(…)ones above, under both bashes. Only "a bad title is still linted" passed there.-R, heredoc query, shell options), under both bashes./bin/bash3.2, gave 135 ok / 67 FAIL, every failure "TK_SUBS[@]: unbound variable".make test-hook-commandsalso caught the bash 3.2 bug: "the real gate blocks gh pr ready" exited 1 instead of 2, in all three shells.shanddash.make lint-shnow also checks the extensionless.githooks/files.--body: 11 s with the old character loop, under 1 s after); not re-measured after the per-call rewrite.Related Issues
Closes #764
Closes #678
Closes #787
Closes #790
Supersedes #766 (rebuilt on
mainafter #785).Part of #740
Based on
main. The two PRs it was stacked on, #793 (-trimpath) and #794 (the per-PR build cache), have merged. Gate forms review found but left out of this PR, as less common than the ones it covers: #797.🤖 Generated with Claude Code
https://claude.ai/code/session_01Wjv2nu841pxTMdUWSAPmDH